組込み現場の「C++」プログラミング 明日から使える徹底入門

高木 信尚(株式会社クローバーフィールド

2.3 継承と仮想関数

オブジェクト指向では継承は重要な要素です.C++にも,継承を表現するための言語仕様が備わっています.

2.3.1 クラスの継承

継承というのは,他のクラスの持つ性質を受け継いだ別のクラスを定義することを意味しています.具体的な使用例は後回しにして,まずは構文から見ていくことにします.

struct A
{
    int a;
    int foo(int arg);
};
struct B : A
{
    int b;
    void bar();
};

上記の例では,クラスAを継承してクラスBを定義しています*13.このようにクラスを継承するときには,structやclassといったクラスキーとクラス名の後に,継承しようとするクラス名をコロン(:)で区切って記述します.上記では,「struct B : A」の部分がそれに当たります.ただし,共用体から継承することはできませんし,他のクラスを継承して共用体を定義することもできません.

継承は(そうした設計の良し悪しは別として)何段階にでも行うことができます.たとえば,クラスBを継承してクラスCを定義し,そのクラスCを継承してクラスDを定義し……といった具合にです.

話をクラスAとクラスBに戻します.派生クラスであるクラスBは,クラスAの性質をそのまま継承することになります.すなわち,クラスAのデータメンバーであるaは,クラスBでもデータメンバーになります.クラスAのメンバー関数であるfooは,クラスBでもメンバー関数になります.つまり,クラスBを外部から見ると,そのメンバーは,次のようなクラスであるかのように扱うことができます.

struct B
{
    int a;
    int b;
    int foo(int arg);
    void bar();
};

また,ちょっと難解かもしれませんが,クラスBへのポインタはクラスAへのポインタに暗黙的に型変換することができます.つまり,A*やconst A*を引数として要求している関数に,クラスBへのポインタを実引数として渡すことができるわけです.同様に,クラスAへの参照をクラスBのオブジェクトで初期化することも可能です.

このように,継承を使用することで,クラスの機能拡張を基底クラスへの差分として記述することができるようになるわけです.

*13 ここではstructを用いて構造体としていますが,議論の本質ではありません.

2.3.2 基底クラスのアクセス制御

先ほど,「このようにクラスを継承するときには,structやclassといったクラスキーとクラス名の後に,継承しようとするクラス名をコロン(:)で区切って記述します」と書きました.しかし,実際は,この書き方をした場合,使用したクラスキーによって意味が異なります.

クラスキーがstructの場合,クラスのメンバーはデフォルトで公開(public)でしたが,継承の場合も基底クラスはデフォルトで公開になります.それに対して,クラスキーがclassの場合,クラスのメンバーがデフォルトで非公開(private)であるように,基底クラスもデフォルトでは非公開になります.

基底クラスが非公開(private)ということは,基底クラスの持つ全メンバーは,派生クラスの非公開メンバーとして扱われます.また,そのクラスのメンバー関数の中や静的データメンバーの初期化を除き,非公開の基底クラスへのポインタや参照に,暗黙的に型変換されることもありません.

struct A
{
    int a;
    int foo(int arg);
};
class B : A
{
};
B b;
A* p = &b; // ← エラー! 

このように,メンバーに対するものと同様,基底クラスに対しても公開や非公開(そして,限定公開)といったアクセス制御があります.上記はデフォルトのアクセス制御についての話でしたが,明示的にアクセス制御を行うこともできます.その場合,次のように記述します.

class B : public A
{
};
B b;
A* p = &b; // ← OK! 

すなわち,基底クラスを指定する際に,publicやprivate(および,protected)といったアクセス指定子を記述すればよいのです.

ここで,これまではあまり詳しく触れてこなかった限定公開(protected)についても解説します.限定公開というのは,公開と非公開の中間的な存在で,派生クラスからのアクセスを許可するものです.すなわち,そのクラスと派生クラス(および,さらにその派生クラス)からは制限なくアクセスすることができますが,クラスの外部からのアクセスは禁止されます.

メンバーを非公開(private)にしてしまうと,いくら継承によってクラスの性質が引き継がれるといっても,派生クラスからそのメンバーにアクセスすることができなくなります.派生クラスからもメンバーにアクセスする必要があるなら,限定公開にする必要があります.

class A
{
public:
    int a;
protected:
    int b; // ← 限定公開のメンバー 
private:
    int c;
};
class B : public A
{
public:
    void func()
    {
        int x;
        x = b.a; // ← OK! 
        x = b.b; // ← OK! 
        x = b.c; // ← エラー! 
    }
};
int main()
{
    B obj;
    int x;
    x = obj.a; // ← OK! 
    x = obj.b; // ← エラー!(限定公開なので,クラスの外からはアクセスできない) 
    x = obj.c; // ← エラー!
    return 0;
}

2.3.3 多重継承と仮想継承

クラスの継承において,基底クラスは次のように複数指定することもできます.このように,カンマ(,)で区切って複数の基底クラスを指定すれば,それぞれのクラスから継承することができます.このように,複数の基底クラスから継承することを「多重継承」といいます.

struct A
{
     …
};
struct B
{
     …
};
struct C : A, B
{
     …
};

上記の例のような単純な構造の場合はよいのですが,多重継承を使うと次のようなこともできてしまいます.

struct A
{
     …
};
struct B : A
{
     …
};
struct C : A
{
     …
};
struct D : B, C
{
     …
};

このような場合,Dの中に含まれるAのメンバーにアクセスしようとした場合,Bの基底クラスであるAのメンバーにアクセスしようとしたのか,Cの基底クラスであるAのメンバーにアクセスしようとしたのかがわからなくなり,いろいろと混乱の原因になります.

このような継承を「菱形継承」といいます(図2.1参照).そして,「菱形継承問題」という言葉もあるぐらい,多重継承に絡む厄介な問題となっています.

●図2.1 菱形継承

図2.1 菱形継承

菱形継承問題を解決するには,B経由のAも,C経由のAも,同じ実体を表すことにするのも1つの方法です.C++では,これを実現するために「仮想継承」という言語仕様を提供しています.

struct A
{
     …
};
struct B : virtual A
{
     …
};
struct C : virtual A
{
     …
};
struct D : B, C
{
     …
};

上記のように,派生クラスを指定するときにvirtualを指定すれば,それは仮想継承を意味します.仮想継承における基底クラスは「仮想基底クラス」と呼ばれます.仮想継承にしておけば,DからAのメンバーにアクセスしようとした場合でも,実体は1つしかありませんから,菱形継承に絡む複雑な問題はなくなります.

しかし,仮想継承を実現するためにはそれなりにオーバーヘッドがありますし,あらかじめ菱形継承になることを予測して,BやCを設計しておかなければならないなど,決して使い勝手の良いものではありません.

多重継承にはほかにもいろいろと難しい問題があります.慣れないうちはむやみに多重継承を使わないほうが無難です.

2.3.4 基底クラスの初期化

基底クラスのコンストラクタが引数を受け取る場合,データメンバーの初期化と同じように,派生クラスのコンストラクタから基底クラスのコンストラクタを呼び出す必要があります.

コンストラクタで,データメンバーと基底クラスの初期化を行う場合,必ず基底クラスの初期化が最初に行われます.多重継承によって基底クラスが複数ある場合,原則としてクラスの定義時に指定した順に初期化が行われます.ただし,仮想基底クラスは必ず最初に初期化されます.

例を1つ挙げましょう.

struct A
{
    A(int arg);
};
struct B
{
    B(int arg);
};
struct C : A, virtual B
{
    int x;
    int y;
    C()
        : y(1), x(0), A(2), B(1)
    {
    }
};

上記の例では,初期化子はy,x,A,Bの順に記述されていますが,それとは無関係に,B,A,x,yの順に初期化が行われます.混乱の原因にもなるので,コンストラクタ初期化子を記述する際は,実際に初期化される順に記述することをお勧めします.

2.3.5 仮想関数

クラスを継承することで,派生クラスは基底クラスの性質を継承することができますが,特定のメンバー関数の振る舞いを,実際のクラス型に応じて変更したいことはよくあります.たとえば,よくある例としては,shapeクラスから派生したcircleクラスとrectangleクラスでは,描画に使用するdrawメンバー関数の振る舞いを変更したいはずです.メンバー関数の振る舞いを変更できなければ,データとして式を持たせたり,別途用意した描画用の関数を,関数へのポインタであるデータメンバーに格納するなどの方法を採らざるをえません.具体的にはこんな感じです.

class shape
{
    void (*pfn_draw)();
public:
    shape(void (*pfn)())
        : pfn_draw(pfn)
    {
    }
    void draw()
    {
        (*pfn_draw)();
    }
};
class circle : public shape
{
    static void draw_fn();
public:
    circle()
        : shape(&draw_fn)
    {
    }
};
int main()
{
    circle obj;
    shape* p = &obj;
    p->draw(); // ← circle::draw_fncircle::draw_fn関数を呼び出す 
    return 0;
}

確かにこうした方法でも実現はできるのですが,このようなメンバー関数が増えてくると記述が面倒ですし,間違いの原因にもなります.効率面でもいろいろと不安が残ります.

そこで,このような問題を解決するための言語仕様がC++には備わっています.それが「仮想関数」です.仮想関数というのは,早い話が派生クラスで上書き可能な関数のことです.メンバー関数を仮想関数にするには,virtual指定子を用いて次のように記述します.

class shape
{
public:
    virtual void draw()
    {
         …
    }
};
class circle : public shape
{
public:
    void draw()
    {
         …
    }
};
int main()
{
    circle obj;
    shape* p = &obj;
    p->draw(); // ← circle::draw関数を呼び出す 
    return 0;
}

ここで,circleクラスのdrawメンバー関数を宣言する際には,virtual指定子は付けても付けなくてもかまいません.

circleクラスのdrawメンバー関数は,shapeクラスのdrawメンバー関数を上書きしたことになります.したがって,オブジェクトがcircle型として生成されたのであれば,たとえshape型へのポインタや参照を介してアクセスしたとしても,circle::drawが呼び出されることになります.

このように,C++では,仮想関数を用いることで,メンバー関数の振る舞いを派生クラスごとに変更することが簡単にできます.なお,Javaなどでは,メンバー関数(Javaではメソッド)はデフォルトで仮想関数になるのに対して,C++では,virtual指定子を明示的に付ける必要があります.これは,仮想関数にすると若干のオーバーヘッドが発生するからで,効率を優先するC++では非仮想関数をデフォルトにしているわけです.

仮想関数の使い方については,「2.4.1 仮想関数と多相オブジェクト」で詳しく解説するので,ここではこの程度にとどめておきます.

2.3.6 純粋仮想関数

先ほどは仮想関数について解説しましたが,今回は仮想関数の一種である「純粋仮想関数」について解説します.先ほど取り上げたshapeクラスを例にとって,純粋仮想関数について簡単に書いてみることにします.

shapeクラスは図形を表現するための基底クラスですので,その具体的な形状が決まっていません.具体的な形状は派生クラスで,cireleクラスであれば円,rectangleクラスであれば長方形を表すことになります.具体的な形状が決まらないと図形を描画することができませんから,shapeクラスのdrawメンバー関数は何も処理することができません.

このような場合,drawメンバー関数を純粋仮想関数とすることで,派生クラスでdrawメンバー関数を上書きすることを強制することができます.C++ではこのように,特定のセマンティックスを強制するための表現方法がいろいろと備わっています.つまり,Cの場合はプログラマーの責任で注意深く扱わなければならなかったものも,コンパイラに検出させることができるようになっているわけです.

それでは,純粋仮想関数の記述のしかたです.

class shape
{
public:
    virtual void draw() = 0;
};

このように,仮想関数が純粋仮想関数であることを指定するには,= 0を記述します.ちょうど,関数へのポインタを空ポインタで初期化するのと同じイメージです.

純粋仮想関数を1つでも含むクラスは「抽象クラス」と呼ばれ,それ自体の型を持つオブジェクトを生成することができません.抽象クラスは,必ず派生クラスを定義して,その派生クラス型のオブジェクトを生成する必要があります.もちろん,派生クラスでも,すべての純粋仮想関数を上書きしなかった場合には,その派生クラスも抽象クラスとなり,その型のオブジェクトを生成できません.

2.3.7 実行時型識別(RTTI)

クラスの継承を有効活用しようとすると,基底クラスへのポインタや参照を使って派生クラスのオブジェクトを指す機会も少なくありません.いったん,基底クラスへのポインタや参照で受けたなら,個々の派生クラス独自のメンバー関数を呼び出すなど,実際に指しているオブジェクトの型に依存した操作は原則として行うべきではありません.

しかし,それはあくまでも建前であり,現実には実際のオブジェクトの型に依存した実装をせざるをえない状況も出てきます.Cの場合でも,何らかのポインタをvoid*型の変数に格納し,実際に使うたびに元の型にキャストすることが少なからずあるかと思います.それと似たようなことが,継承関係のあるクラスでも起きるわけです.

struct A
{
    virtual void f();
};
struct B : A
{
};
A* ptr = new B;

上記のようなクラスAおよびクラスBがあり,クラスAへのポインタptrがクラスBのオブジェクトを指しているとします.この場合,ptrの形式的な型であるクラスAは「静的な型」であり,実際に指しているオブジェクトの型であるクラスBが「動的な型」であるといいます.

「実行時型識別」(RTTI:Run-Time Type Identification)は,この動的な型を識別するための機能であり,C++ではdynamic_castおよびtypeidという2種類の演算子によって,この機能を利用することができます.なお,こうした実行時型識別の機能を利用できるのは,仮想関数を1つ以上持つ多相クラス(「2.4.1 仮想関数と多相オブジェクト」参照)でなければなりません.

dynamic_cast演算子

「dynamic_cast」は,ポインタや参照が指している動的な型に基づいた型変換を行うためのキャスト演算子です.基底クラスのポインタ(や参照)から派生クラスのポインタ(や参照)への型変換であるダウンキャストや,多重継承したクラスのオブジェクトを指す1つの基底クラスのポインタ(や参照)から他の基底クラスのポインタ(や参照)への型変換であるクロスキャストが可能になります.

struct A
{
    virtual void f();
};
struct B
{
    virtual void g();
};
struct C : A, B
{
};
C c;
A* pa = &c;
B* pb = dynamic_cast<B*>(pa); // ← クロスキャスト 
C* pc = dynamic_cast<C*>(pa); // ← ダウンキャスト

上記ではうまく型変換できる場合だけを紹介しました.しかし,型変換がうまくいくかどうかは実行時でなければわかりませんので,当然型変換に失敗するケースが出てきます.型変換に失敗した場合,ポインタのキャストの場合にはdynamic_castは空ポインタ(NULL)を返します.参照へのキャストの場合は空ポインタを返せませんので,代わりにstd::bad_cast例外が送出されます.std::bad_castクラスは<exception>ヘッダで定義されています.

A a;
A* pa = &a;
B* pb = dynamic_cast<B*>(pa);  // ← 空ポインタ(NULL) 
B& rb = dynamic_cast<B&>(*pa); // ← std::bad_cast例外が送出される 

typeid演算子

「typeid演算子」は,ちょうどsizeof演算子と同様の使い方をします.すなわち,typeid演算子のオペランドには,次のように型名または式を指定することができます.

typeid(型名)
typeid 式

typeid演算子は,std::type_infoクラスへのconst参照を返します.std::type_infoクラスは<typeinfo>ヘッダで定義されています.

namespace std
{
    class type_info
    {
    public:
        const char* name() const;
        bool before(const type_info& rhs) const;
        bool operator==(const type_info& rhs) const;
        bool operator!=(const type_info& rhs) const;
         …
    };
}

std::type_infoクラスは等価演算子を多重定義しているので,typeid演算子の結果を比較すれば,型が同じかどうかを調べることができます.また,beforeメンバー関数を使えば型の順序付けを行えるので,ソートや二分探索などで使うことができます.ただし,具体的にどんな基準で順序付けを行うのかは決まっていません.nameメンバー関数は,型を表す文字列を返しますが,

主にデバッグ用の用途を想定したものらしく,どんな文字列が変えるのかは完全に処理系に依存します(必ずしも可読性が高い文字列が変えるとはかぎりません).

typeid演算子に,仮想関数を持つクラスへのポインタや参照をオペランドとして指定すると,それが指している動的な型の情報を持つstd::type_infoオブジェクトへの参照を返します.もし,オペランドに空ポインタを指定した場合には,std::bad_cast例外が送出されます.std::bad_cast例外は<typeinfo>ヘッダで定義されています.